Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/#1032 working veto system #1033

Open
wants to merge 15 commits into
base: develop
Choose a base branch
from

Conversation

K-ETFreeman
Copy link

@K-ETFreeman K-ETFreeman commented Dec 6, 2024

Working on #1032
I need some feedback, what is right, what is wrong, what needs to be changed, etc

@Sheikah45 Sheikah45 requested a review from Askaholic December 6, 2024 19:02
Comment on lines 549 to 550
if (tokensTotalPerPlayer[player.id] > veto_tokens_per_player):
raise RuntimeError(f"Player {player.id} has too many vetoes!")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not do validation of the number of tokens per player when starting the game. This validation and error raising should happen when the server receives the set_vetoes message. This is because as it is now a player could send the server more than the max amount of vetoes and then cause a game to never be able to launch.

Comment on lines 705 to 723
def calculate_dynamic_tokens_per_map(self, M: float, tokens: list[int]) -> float:
sorted_tokens = sorted(tokens)
if (sorted_tokens.count(0) >= M):
return 1

result = 1; last = 0; index = 0
while (index < len(sorted_tokens)):
(index, last) = next(((i, el) for i, el in enumerate(sorted_tokens) if el > last), (len(sorted_tokens) - 1, sorted_tokens[-1]))
index += 1
divider = index - M
if (divider <= 0):
continue

result = sum(sorted_tokens[:index]) / divider
upperLimit = sorted_tokens[index] if index < len(sorted_tokens) else float('inf')
if (result <= upperLimit):
return result

raise Exception("Failed to calculate dynamic tokens per map: impossible vetoes setup")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly here we are throwing an exception based on user input so a user could craft a veto selection that could cause the majority of games to fail to start. It would be better to have some default value rather than throwing an exception.

@K-ETFreeman
Copy link
Author

K-ETFreeman commented Dec 6, 2024

also idk how least_common should work
currently set just X2 multiplier if map is least_played and not vetoed by anyone
but we can set to X5 or X10, it seems really harmless
but maybe not, depends how exactly this history works, is it being reset or not

@Askaholic Askaholic force-pushed the develop branch 3 times, most recently from 75a18d1 to 0a667d2 Compare December 22, 2024 22:38
Copy link
Contributor

@Gatsik Gatsik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only wanted to ask what calculate_dynamic_tokens_per_map function is doing, because i don't fully comprehend it from the code, but also left some other comments/suggestions.

I am not a maintainer of this repo, therefore feel free to ignore them or use them at your own risk

@@ -523,7 +542,23 @@ def get_displayed_rating(player: Player) -> float:
pool = queue.get_map_pool_for_rating(rating)
if not pool:
raise RuntimeError(f"No map pool available for rating {rating}!")
game_map = pool.choose_map(played_map_ids)

pool, _, _, _, max_tokens_per_map, minimum_maps_after_veto = queue.map_pools[pool.id]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use *_ instead of 3 _ to unpack unneeded variables


pool, _, _, _, max_tokens_per_map, minimum_maps_after_veto = queue.map_pools[pool.id]

vetoesMap = defaultdict(int)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is it suddenly in camelCase?

vetoesMap[m.map_pool_map_version_id] += player.vetoes.get(m.map_pool_map_version_id, 0)

if (max_tokens_per_map == 0):
max_tokens_per_map = self.calculate_dynamic_tokens_per_map(minimum_maps_after_veto, vetoesMap.values())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculate_dynamic_tokens_per_map expects list[int], but vetoesMap.values() are actually not a list. if you want to annotate dict's values, you can use ValuesView from collections.abc

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good notice
i'll replace calculate_dynamic_tokens_per_map tokens type with Iterable[int]

@@ -673,6 +708,35 @@ async def launch_match(
if player not in connected_players
])

def calculate_dynamic_tokens_per_map(self, M: float, tokens: list[int]) -> float:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you trying to achieve sum(weights) = M?

Copy link
Author

@K-ETFreeman K-ETFreeman Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculate_dynamic_tokens_per_map finds the minimal possible max_tokens_per_map while still respecting M

Copy link
Contributor

@Gatsik Gatsik Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but when do you decide that max_tokens_per_map can't be reduced anymore?

Copy link
Author

@K-ETFreeman K-ETFreeman Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its just some math
it solves series of equations, if vetoes set correctly, one of them is guaranteed to be correct answer
if vetoes set incorrectly, it will return 0

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll prepare some explanation how math works

@@ -78,12 +78,15 @@ def add_map_pool(
self,
map_pool: MapPool,
min_rating: Optional[int],
max_rating: Optional[int]
max_rating: Optional[int],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are all of those arguments not inside MapPool 🤔 ?

Copy link
Author

@K-ETFreeman K-ETFreeman Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because its not in the mapPool in the database, but in matchmaker_queue_map_pool (queue bracket), so should be consistent between repos
and yea its that way in db due to some reasons which were discussed long ago (f.e. u can use same pool for multiple brackets)

Copy link
Author

@K-ETFreeman K-ETFreeman Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

raise ValueError("Incorrect vetoes dictonary")
self._vetoes = value

async def update_vetoes(self, pools_vetodata: list[tuple[list[int], int, int]], current: dict = None) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could create a container (NamedTuple) for this tuple[int], int, int structure and avoid those long type hints and maybe call it MapPoolVeto

also, if you put tokens and ratings information in MapPool (in database they are in the same table, why are they separated here?) you won't even need this additional structure and you will be able to pass MapPool and use its properties

finally, this can also possibly remove get_pools_veto_data method since it just rearranges MapPool's internals

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vetoes are not in map pool in database https://faforever.github.io/db/tables/map_pool.html
vetoes are here: https://faforever.github.io/db/tables/matchmaker_queue_map_pool.html
the same place where min and max rating, outside of map pool itself

Copy link
Author

@K-ETFreeman K-ETFreeman Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also i am not sure wdym removing get_pools_veto_data
it gives only necessary data and readable name for it, allows to avoid any code duplication and keeps the code more clean
i am using it in multiple places so if we remove it code will become just worse

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, i see

What i mean is that now matchmaker_queue_map_pool has additional 3 new fields and it may be viable to create corresponding data container (see, for example, FeaturedMod).
This way you preserve readable names and also make it easier to understand what's passed to other functions

For example, in my opinion

async def update_vetoes(self, list[MatchmakerQueueMapPool], current: dict | None) -> None

is easier to follow than to find calls to update_vetoes and discover what's inside of list[tuple[list[int], int, int]]

also,

map_pools: Iterable[MatchmakerQueueMapPool]

is easier to understand than

map_pools: Iterable[tuple[MapPool, Optional[int], Optional[int], int, int, float]] = ()

where MatchmakerQueueMapPool is

class MatchmakerQueueMapPool(NamedTuple):
    map_pool: MapPool
    min_rating: int | None
    max_rating: int | None
    veto_tokens_per_player: int
    max_tokens_per_map: int
    minimum_maps_after_veto: float

finally, this way long unpacking can be avoided and instead of

for pool, *_, veto_tokens_per_player, max_tokens_per_map, _ in queue.map_pools.values():

you can easily access named attributes

for pool in queue.map_pools.values():
    pool.veto_tokens_per_player...

Copy link
Author

@K-ETFreeman K-ETFreeman Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, yea, but this going into refactoring existing code zone rather than implementing new features so i was not sure should i do it here or not

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using and changing to a NamedTuple should be fine and probably preferred as Gatsik says

Copy link
Author

@K-ETFreeman K-ETFreeman Jan 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cannot put named tuple MatchmakerQueueMapPool right inside types.py due to circular dependencies happening (map_pool.py uses types and types uses map_pool.py in this case)
is it ok no place it right inside map_pool.py?

map_id = random.choices(least_common, weights=weights, k=1)[0][0]
return self.maps[map_id].get_map()
# Multiply weight by 2 if map is least common and not vetoed by anyone
mapList = list((map.map_pool_map_version_id, map, 2 if (map.id in least_common_ids) and (vetoesMap.get(map.map_pool_map_version_id, 0) == 0) else 1) for id, map in self.maps.items())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't you feel like this line is a bit long?)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

long lines for long PP

@K-ETFreeman
Copy link
Author

i'll add some improvements soon

@K-ETFreeman
Copy link
Author

ok not soon but tomorrow ;)

4) otherwise
solving equation for current group
and checking the result vs upper border
and upper border is equal to the amount of tokens applied to the map next to last map in our group, or infinity if there is no such one
Copy link
Contributor

@Gatsik Gatsik Jan 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the explanation could be shorter

Lets denote max_tokens_per_map = $d$ and len(tokens) = $n$. You are trying to achieve
$\sum_{i=1}^{n}(1 - \frac{a_{i}}{d}) = M$, which means $d = \frac{\sum_{i=1}^{n}a_{i}}{n - M}$

If some $a_{i} &gt; d$ then $1 - \frac{a_{i}}{d} &lt; 0$, which results in contributing nothing due to $weight = \max(0, 1 - \frac{a_{i}}{d})$ . Will excluding those ${a_{i}}$ from $tokens$ decrease $d$?

Given sorted array, excluding the last element ${a_{n}}$ from $tokens$ gives new $d_{1} = \frac{\sum_{i=1}^{n-1}a_{i}}{n - M - 1}$.
This excluding won't help if $d_{1} &gt; d$

Lets denote $\sum_{i=1}^{n-1}a_{i} = S$, then $d &lt; d_{1}$ is achieved when $\frac{S + a_{n}}{n - M} &lt; \frac{S}{n - M - 1}$, which means $a_{n} &lt; \frac{S + a_{n}}{n - M}$

But $S + a_{n} = \sum_{i=1}^{n}a_{i}$, therefore $a_{n} &lt; \frac{\sum_{i=1}^{n}a_{i}}{n - M}$$a_{n} &lt; d$

This means, that we can stop trying to decrease $d$ when $\max(tokens) &lt; d$

And you can describte the algorithm as easy as:

  • Find $d = \frac{\sum_{i=1}^{n}a_{i}}{n - M}$, if $d &gt; \max(tokens)$ then $d$ is found. otherwise exclude all $a_{i} \geq d$. Repeat while $n &gt; M$

(And of course handle special case when $d = 0$, which means $d$ can be anything and you've chosen it to be $1$)

solving equation for current group
and checking the result vs upper border
and upper border is equal to the amount of tokens applied to the map next to last map in our group, or infinity if there is no such one
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe put docstring inside the function definition?

minimum_maps_after_veto = 1
self._logger.error(f"Wrong vetoes setup detected for pool {pool.id} in queue {queue.id}")
result.append(
MatchmakerQueueMapPoolVetoData(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't really understand why you need MatchmakerQueueMapPoolVetoData. Why don't you just initialize MatchmakerQueueMapPool with correct vetoes setup and use it?

Copy link
Author

@K-ETFreeman K-ETFreeman Jan 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

atleast to avoid having any possible issues with queue.map_pools.clear() in update_data
theoretically user can change vetoes mid-queues-update, then server will delete all user's vetoes and he will need to apply all of them again which is very bad

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Askaholic said "Technically we do run the matchmaking algorithm in a separate thread because it is the one piece of the code that has the potential to block the rest of the server if there are a lot of players in queue."

So i prefer to have robust solution here

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that statement is relevant to vetos because the 'chose_map' stage happens after the matchmaking algorithm has finished. But you should probably set it up in such a way that vetos are locked in the moment a match is found like we do with factions in the PlayerParty.on_matched function. I think keeping the behavior for choosing vetos and factions consistent makes the most sense.

Copy link
Author

@K-ETFreeman K-ETFreeman Jan 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that statement is relevant to vetos because the 'chose_map' stage happens after the matchmaking algorithm has finished. But you should probably set it up in such a way that vetos are locked in the moment a match is found like we do with factions in the PlayerParty.on_matched function. I think keeping the behavior for choosing vetos and factions consistent makes the most sense.

its not about chose_map, it about command_set_player_vetoes in lobbyconection.py
it uses matchmaker map pool data and this is async function, and i am not 100% sure how this works here

also whats the point of locking vetoes when match found? what can possibly go wrong if we dont do that?


class MatchmakerQueueMapPool(NamedTuple):
map_pool: MapPool
min_rating: int | None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use Optional[int], because looks like server hasn't adopted this syntax yet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants